เอกสารสเปคการพัฒนาหน้าจอ "บทบาทและสิทธิ์การเข้าถึง" (Role & Access Control) สำหรับทีม Frontend (Next.js 14) และ Backend (Go-Chi v5) — ครอบคลุม Permission Matrix 7×26 · Add/Edit Role modal · Auto/Manual code switch · Audit log · system roles protection · ตัวอย่างโค้ด + DFD + State Sequence Diagram
platform_admin + org_admin · path: /[locale]/setting/master/rbacหน้านี้คือศูนย์กลางสำหรับ กำหนดบทบาทผู้ใช้งานและสิทธิ์การเข้าถึงโมดูล ในระบบ ConstructQ · รองรับ 7 system roles + custom roles · ใช้ Permission Matrix 26 modules × 5 actions (R/C/E/D/A) เป็น single source of truth
RequirePerm("m_rbac", action)platform_admin — F (R/C/E/D/A) ทุก role · ทุก orgorg_admin — F แต่จำกัดเฉพาะ org ของตัวเองhasPerm())
CLAUDE.md §7 RBACConstructQ_RBACSettings.html — HTML prototypeDocument/ConstructQ_Role&Privilege.pdf — official matrixUser Role.pdf — role descriptionConstructQ_Schema.sql — roles · modules · permissions tables
// src/app/[locale]/(app)/setting/master/rbac/ ├── page.tsx // Server Component · fetch initial ├── actions.ts // Server Actions (RBAC mutations) ├── schema.ts // Zod schemas (Role · Permission) ├── store.ts // Zustand UI state └── components/ ├── RBACPageClient.tsx // Top-level client wrapper ├── RolesList.tsx // 7 roles + custom + Add button ├── PermissionMatrix.tsx // 26 modules × 5 actions ├── RoleModal.tsx // Create/Edit role dialog ├── CodeSwitcher.tsx // Auto/Manual code toggle └── DeleteRoleConfirm.tsx // Soft-delete confirm
// src/app/[locale]/(app)/layout.tsx — already wraps AppShell export default async function AppLayout({ children }: { children: React.ReactNode }) { return ( <AppShell> <Sidebar /> <Topbar /> <Breadcrumb /> <main className="content-wrap">{children}</main> </AppShell> ); }
// page.tsx import { requirePerm } from '@/lib/rbac/guard'; import { RBACPageClient } from './components/RBACPageClient'; export default async function RBACPage() { await requirePerm('m_rbac', 'view'); // 🛡️ guard const [roles, modules, permissions] = await Promise.all([ fetch(`${API}/api/roles`).then(r => r.json()), fetch(`${API}/api/modules`).then(r => r.json()), fetch(`${API}/api/permissions`).then(r => r.json()) ]); return <RBACPageClient initialRoles={roles} modules={modules} initialPermissions={permissions} />; }
| Component | Type | Reason |
|---|---|---|
page.tsx | Server | Initial fetch + RBAC guard |
RBACPageClient | Client | state management สำหรับเลือก role + filter |
RolesList | Client | onClick → update Zustand selectedRoleId |
PermissionMatrix | Client | Checkbox toggles + optimistic update |
RoleModal | Client | React Hook Form + Zod |
actions.ts | Server | 'use server' · mutations |
| Code | ชื่อ (TH) | ชื่อ (EN) | System? |
|---|---|---|---|
platform_admin | แอดมินแพลทฟอร์ม | Platform Admin | SYSTEM |
org_admin | แอดมินบริษัทฯ | Organize Admin | SYSTEM |
pm | ผู้จัดการโครงการ | Project Manager | CUSTOM |
qc_mgr | ผู้จัดการตรวจสอบคุณภาพ | QC Manager | CUSTOM |
qc_insp | ผู้ตรวจสอบคุณภาพ | QC Inspector | CUSTOM |
sub | ผู้แก้ไขงาน/ผู้รับเหมาช่วง | OP/Sub Contractor | CUSTOM |
ceo | ผู้บริหาร | CEO | CUSTOM |
| Code | Letter | R | C | E | D | A | คำอธิบาย |
|---|---|---|---|---|---|---|---|
F | Full | ✓ | ✓ | ✓ | ✓ | ✓ | Full Access |
4 | RCED | ✓ | ✓ | ✓ | ✓ | — | R/C/E/D (no Approve) |
3 | RCE | ✓ | ✓ | ✓ | — | — | R/C/E (no Delete/Approve) |
V | View | ✓ | — | — | — | — | Read only |
N | None | — | — | — | — | — | No access · ซ่อนเมนู |
// src/lib/rbac/matrix.ts export type PermCode = 'F' | '4' | '3' | 'V' | 'N'; export const ROLE_INDEX: Record<RoleCode, number> = { platform_admin: 0, org_admin: 1, pm: 2, qc_mgr: 3, qc_insp: 4, sub: 5, ceo: 6 }; // Order: [platform_admin, org_admin, pm, qc_mgr, qc_insp, sub, ceo] export const PERM_MATRIX: Record<ModuleId, PermCode[]> = { m_dash: ['F','F','F','F','4','4','4'], m_proj: ['F','F','F','N','N','N','N'], m_check: ['F','F','V','F','N','N','N'], m_rpt: ['4','4','4','4','3','3','3'], m_terms: ['4','N','N','N','N','N','N'], m_audit: ['4','N','N','N','N','N','N'], // ... full 26 modules }; export const EXPANDED_PERMS: Record<PermCode, Record<ActionCode, boolean>> = { F: { view:true, create:true, edit:true, delete:true, approve:true }, '4': { view:true, create:true, edit:true, delete:true, approve:false }, '3': { view:true, create:true, edit:true, delete:false, approve:false }, V: { view:true, create:false, edit:false, delete:false, approve:false }, N: { view:false, create:false, edit:false, delete:false, approve:false } };
<div className="grid grid-cols-12 gap-6"> <aside className="col-span-3"> <RolesList /> </aside> <main className="col-span-9"> <PermissionMatrix roleId={selectedRoleId} /> </main> </div>
| Component | Type | Behavior |
|---|---|---|
<RolesList /> | Client | Map roles → role card |
<RoleCard /> | Client | onClick → setSelectedRoleId · highlight active |
<AddRoleButton /> | Client | open RoleModal mode='create' |
<SystemBadge /> | Client | Show "SYS" pill for platform/org admin |
// Zustand store export const useRBACStore = create<State>((set) => ({ selectedRoleId: 'platform_admin', // default setSelectedRoleId: (id: string) => set({ selectedRoleId: id }) })); // RoleCard click handler const selectRole = (id: string) => { setSelectedRoleId(id); analytics.track('rbac.role_selected', { role_id: id }); };
const handleEdit = (role: Role) => { if (role.is_system) { toast.warning(t('rbac.systemRole.cannotEdit')); // ⚠️ ยังเปิด modal ได้ — แต่ name + code disabled } openRoleModal({ mode: 'edit', role }); }; const handleDelete = (role: Role) => { if (role.is_system) { toast.error(t('rbac.systemRole.cannotDelete')); return; } openDeleteConfirm(role); };
platform_admin + org_admin ห้ามแก้ code และห้ามลบ · ตรวจซ้ำใน Server Action ก่อน DB write
'use client'; import { useState, useTransition } from 'react'; import { updatePermissionAction } from '../actions'; interface Props { roleId: string; modules: Module[]; permissions: Permission[]; editMode: boolean; } export function PermissionMatrix({ roleId, modules, permissions, editMode }: Props) { const [optimistic, setOptimistic] = useState(permissions); const [pending, startTransition] = useTransition(); const grouped = groupBySection(modules); const handleToggle = (moduleId: string, action: ActionCode, checked: boolean) => { // 1) Optimistic UI setOptimistic(prev => applyToggle(prev, roleId, moduleId, action, checked)); // 2) Server Action startTransition(async () => { try { await updatePermissionAction({ roleId, moduleId, action, granted: checked }); } catch (e) { setOptimistic(permissions); // rollback toast.error(t('rbac.updateFailed')); } }); }; return ( <div className="divide-y divide-ink-100"> {Object.entries(grouped).map(([section, mods]) => ( <section key={section}> <div className="bg-teal-bg px-4 py-2 font-bold text-teal-dark">{section}</div> {mods.map(m => ( <ModuleRow key={m.id} module={m} permissions={optimistic} roleId={roleId} editMode={editMode} onToggle={handleToggle} /> ))} </section> ))} </div> ); }
// schema.ts export const RoleSchema = z.object({ name_th: z.string().min(2, t('rbac.nameTooShort')).max(50), name_en: z.string().min(2).max(50), code: z.string() .regex(/^[a-z][a-z0-9_]{1,29}$/, t('rbac.codeFormat')), code_auto: z.boolean().default(true), description: z.string().max(500).optional(), color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), is_system: z.boolean().default(false) }); export type RoleForm = z.infer<typeof RoleSchema>;
'use server'; export async function createRoleAction(formData: RoleForm) { await requirePerm('m_rbac', 'create'); const parsed = RoleSchema.safeParse(formData); if (!parsed.success) throw new ValidationError(parsed.error); const idempotencyKey = crypto.randomUUID(); try { const role = await api.post('/api/roles', parsed.data, { headers: { 'X-Idempotency-Key': idempotencyKey } }); revalidatePath('/setting/master/rbac'); return { ok: true, role }; } catch (e) { if (e.code === 'DUPLICATE_CODE') { return { ok: false, error: t('rbac.duplicateCode') }; } throw e; } }
^[a-z][a-z0-9_]{1,29}$const { watch, setValue, register, formState } = useForm<RoleForm>({ resolver: zodResolver(RoleSchema), defaultValues: { code_auto: true, is_system: false } }); const nameEn = watch('name_en'); const codeAuto = watch('code_auto'); const isSystem = watch('is_system'); // Auto-generate code when name_en changes (only in auto mode) useEffect(() => { if (codeAuto && nameEn) { const generated = nameEn .toLowerCase() .trim() .replace(/[^a-z0-9\s]/g, '') .replace(/\s+/g, '_') .slice(0, 30); setValue('code', generated, { shouldValidate: true }); } }, [nameEn, codeAuto, setValue]); // Code input <input {...register('code')} readOnly={codeAuto || isSystem} className={cn('input', (codeAuto||isSystem) && 'bg-ink-50 text-ink-500')} />
| Constraint | Frontend Behavior | Backend Enforcement |
|---|---|---|
ห้ามแก้ code | Input disabled · explanatory tooltip | PATCH /api/roles/{id} reject ถ้า is_system + code changes |
| ห้ามลบ role | Delete button hidden · DELETE returns 403 | DELETE /api/roles/{id} reject 403 |
ห้ามลด platform_admin permissions | Checkbox locked (disabled) สำหรับทุก action | PATCH /api/roles/.../permissions reject ถ้าจะ unset |
| org_admin: ห้ามแก้ในระดับ platform | เฉพาะ platform_admin ผู้ใช้ที่เห็น | RLS · scope by organization_id |
func UpdateRoleHandler(w http.ResponseWriter, r *http.Request) { roleID := chi.URLParam(r, "id") role, err := repo.GetRole(roleID) if err != nil { http.Error(w, "404", 404); return } var input RoleUpdateInput json.NewDecoder(r.Body).Decode(&input) // System role guard if role.IsSystem { if input.Code != nil && *input.Code != role.Code { http.Error(w, "FORBIDDEN: cannot change system role code", 403) return } } // Org scope (non-platform_admin) if ctx.Role != "platform_admin" && role.OrganizationID != ctx.OrgID { http.Error(w, "FORBIDDEN: cross-org", 403) return } updated, err := repo.UpdateRole(roleID, input) // AuditMiddleware logs before/after automatically }
// PATCH /api/roles/{id}/permissions { "permissions": [ { "module_id": "m_ncr", "actions": ["view", "create", "edit", "approve"] }, { "module_id": "m_rpt", "actions": ["view", "create", "edit", "delete"] } ] } // Response: 200 OK + updated permissions list
| Field | Rule | Error message (TH) |
|---|---|---|
name_th | 2-50 ตัวอักษร | ชื่อ TH ต้องมี 2-50 ตัวอักษร |
name_en | 2-50 chars · ASCII | ชื่อ EN ต้องมี 2-50 ตัวอักษร (ASCII) |
code | ^[a-z][a-z0-9_]{1,29}$ | รหัสต้องขึ้นต้นด้วย a-z · มี a-z 0-9 _ เท่านั้น · ยาว 2-30 |
code uniqueness | unique per org | รหัสนี้มีอยู่แล้ว · กรุณาใช้รหัสอื่น |
description | ≤ 500 chars · optional | คำอธิบายเกิน 500 ตัวอักษร |
color | #RRGGBB hex (optional) | รหัสสีไม่ถูกต้อง |
is_system | readonly · server-controlled | — |
| System role code change | rejected by backend | ไม่สามารถเปลี่ยนรหัส system role ได้ |
| Delete role with assigned users | cascade users to ceo or warn | มีผู้ใช้ {n} คนใช้บทบาทนี้ · ต้องย้ายก่อนลบ |
| Action | Module | Audit detail |
|---|---|---|
| Create role | m_rbac | before: null · after: role JSON |
| Update role name/desc/color | m_rbac | before/after diff |
| Update permissions | m_rbac | before: array · after: array · module-level diff |
| Delete role | m_rbac | before: role · after: { deleted_at: now } |
| System role change attempt (rejected) | m_rbac | action: "blocked" · reason: SYSTEM_ROLE |
requirePerm('m_rbac', 'view') ใน page.tsx
hasPerm() ซ่อน edit/delete buttons
Re-check requirePerm() ใน action
Go-Chi RequirePerm(module, action)
PostgreSQL Row-Level Security · org_id filter
| # | เกณฑ์ | Verify by |
|---|---|---|
| 1 | หน้า RBAC ครอบ AppShell · ใช้ Sidebar canonical | Manual + Playwright |
| 2 | เข้าถึงได้เฉพาะ platform_admin + org_admin · บทบาทอื่น 403 | RBAC integration test |
| 3 | Roles list แสดง 7 บทบาท + custom · SYS badge สำหรับ system | Visual + content check |
| 4 | Matrix แสดง 26 modules grouped 6 sections | Visual + data check |
| 5 | View mode: checkboxes disabled · Edit mode: enabled | Widget test |
| 6 | Toggle checkbox → optimistic UI + PATCH success | Integration test |
| 7 | System role: code field disabled · delete button hidden | Manual + e2e |
| 8 | Auto code: type name_en → code generated · slug format | Widget test |
| 9 | Manual code mode: ต้องผ่าน regex ^[a-z][a-z0-9_]{1,29}$ | Form validation test |
| 10 | Duplicate code → 409 + toast แสดง "รหัสนี้มีอยู่แล้ว" | API test |
| 11 | Delete role with users → blocked + warning toast | API test |
| 12 | Audit log บันทึกทุก write · ไม่มี PII | Backend integration test |
| 13 | TH/EN toggle ทำงานทุก section + matrix | i18n test |
| 14 | POST/PATCH มี X-Idempotency-Key | Interceptor test |
| 15 | Performance: matrix render 26×5 = 130 cells ≤ 100ms | Lighthouse |